到目前為止,所有功能都只能用 python3 tests/... 呼叫。今天要把 OptimizedDetector 包裝成 HTTP API,讓明天的 React 前端與其他工具可以透過網路使用衝突檢測。
第 2 週:檢測優化與生產系統
今天要完成:
job_id,檢測在背景執行{"detail": ...} 回傳SRS_USE_OLLAMA=1 改用 Day 12 的 Mistral 版本最直覺的 API 設計是「送出約束 → 等待 → 拿到衝突」。但從 Day 12 的數字來看,使用 LLM 時一份 SRS 要數十秒:
所以採用任務模式:
POST /api/v1/conflicts/detect → 立即回傳 {"job_id": ..., "status": "queued"}
(背景執行檢測)
GET /api/v1/conflicts/{job_id} → 輪詢,直到 status = completed / failed
所有端點都定義在 src/api_main.py,相對於 http://localhost:8000:
| 方法 | 路徑 | 說明 |
|---|---|---|
| GET | / |
API 基本資訊 |
| GET | /api/v1/health |
健康檢查(含目前使用的 LLM) |
| GET | /api/v1/info |
功能與端點清單 |
| POST | /api/v1/conflicts/detect |
提交檢測任務 |
| GET | /api/v1/conflicts/{job_id} |
查詢任務狀態與結果 |
| GET | /api/v1/conflicts |
列出所有任務 |
| GET | /api/v1/metrics |
檢測器統計(LLM 呼叫次數、緩存命中率) |
| GET | /docs、/redoc |
FastAPI 自動產生的互動式文件 |
[{id, text}, ...] 約束清單(與 Day 10-12 的 detector 相同)src/api_main.py(新增)/api/v1/auth/* 與需要登入的 /api/v1/detect
注意:專案中的
src/api_main.py是 Day 13-16 逐步擴充後的版本,開頭已經 import 了 Day 15 的src/database.py與src/api_auth.py。今天只看不需要認證的/api/v1/conflicts/*這組端點,任務狀態存在記憶體的jobs字典裡。
專案結構變化:
srs-review-agent/
├── src/
│ ├── llm_verifier.py ← Day 11(OllamaLLM)
│ ├── performance_optimizer.py ← Day 12(OptimizedDetector)
│ ├── api_main.py ← 【新增】FastAPI 應用
│ └── ...
├── tests/
│ ├── test_day13_api.py ← 【新增】API 測試
│ └── ...
└── requirements.txt ← 已包含 fastapi、uvicorn、httpx
pip install fastapi uvicorn httpx pytest
httpx 是 FastAPI 的 TestClient 需要的套件。也可以直接 pip install -r requirements.txt 安裝整個系列的依賴。
建立 src/api_main.py:
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
import os
import uuid
from src.performance_optimizer import OptimizedDetector
from src.llm_verifier import OllamaLLM
app = FastAPI(title="SRS Review Agent API", version="2.0.0")
# 允許前端(Day 14 的 Vite 開發伺服器)跨域呼叫
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
allow_origins=["*"] 只適合開發環境。正式部署時應改成前端的實際網域。
jobs = {} # 任務狀態(記憶體,重啟即消失;Day 15 改存資料庫)
USE_OLLAMA = os.getenv("SRS_USE_OLLAMA") == "1"
if USE_OLLAMA:
detector = OptimizedDetector(
llm=OllamaLLM(),
cache_path=os.getenv("SRS_CACHE_PATH", ".cache/verify_cache.json"),
)
else:
detector = OptimizedDetector() # 規則 + 模擬驗證,不需要 Ollama
預設模式讓 API 在沒有 Ollama 的環境(CI、前端開發)也能啟動,且回應在毫秒級。detection_kwargs() 會依模式決定檢測參數:使用 LLM 時改成 verify_strategy="all" 並開啟補充層,理由與 Day 12 相同(規則置信度都 ≥ 0.85,selective 不會驗證任何候選)。
detector 是全域單例,所以 Day 12 的記憶體緩存會在不同請求之間共用。
class DetectionOptions(BaseModel):
verify: bool = True
batch_size: int = Field(10, ge=1, le=100)
verify_strategy: str = "selective"
class DetectionRequest(BaseModel):
constraints: List = Field(default_factory=list)
options: Optional[DetectionOptions] = None
constraints 刻意宣告成未指定型別的 List,因為 Day 16 的前端會送純文字清單 ["...", "..."],而今天的端點收的是 [{id, text}]。兩種格式都交給 normalize_constraints() 統一處理:
def normalize_constraints(raw: list) -> List[dict]:
normalized = []
for i, item in enumerate(raw):
if isinstance(item, str):
normalized.append({"id": f"REQ-{i+1}", "text": item})
elif isinstance(item, dict) and item.get("id") and item.get("text"):
normalized.append({"id": str(item["id"]), "text": str(item["text"])})
else:
raise ValueError(f"第 {i+1} 個約束格式錯誤,需為字串或含 id、text 的物件")
return normalized
這裡有一個我踩到的坑:一開始背景任務寫成 [{"id": c.id, "text": c.text} for c in request.constraints]。因為 List 沒有指定元素型別,Pydantic 不會把 dict 轉成物件,c.id 直接丟出 'dict' object has no attribute 'id',每一個任務都是 failed。偏偏原本的測試只檢查「有沒有 status 欄位」,所以測試全過。今天把測試改成必須 completed 且找到 2 個衝突,才抓到這個 bug。
@app.post("/api/v1/conflicts/detect", tags=["衝突檢測 (舊版)"])
async def detect_conflicts_legacy(request: DetectionRequest,
background_tasks: BackgroundTasks):
if len(request.constraints) < 2:
raise HTTPException(status_code=400, detail="至少需要 2 個約束")
if len(request.constraints) > 500:
raise HTTPException(status_code=400, detail="最多 500 個約束")
try:
normalize_constraints(request.constraints) # 格式錯誤在這裡就回 400
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
job_id = f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
jobs[job_id] = {"status": "processing", "start_time": datetime.now().isoformat(),
"request": request}
background_tasks.add_task(run_detection, job_id, request)
return {"job_id": job_id, "status": "queued", "message": "檢測任務已提交 (舊版本)"}
格式檢查在提交時就做,不等背景任務失敗,使用者能立刻拿到 400 與錯誤原因,不用輪詢之後才發現 failed。(「舊版」是 Day 16 加入認證版 /api/v1/detect 之後補上的標籤。)
def run_detection(job_id: str, request: DetectionRequest):
start_time = datetime.now()
try:
constraints = normalize_constraints(request.constraints)
options = request.options or DetectionOptions()
conflicts = detector.detect_conflicts(
constraints,
**detection_kwargs(options.verify, options.batch_size, options.verify_strategy),
)
jobs[job_id]["results"] = {
"conflicts_found": len(conflicts),
"conflicts": [{"req_id_1": c.req_id_1, "req_id_2": c.req_id_2,
"type": c.conflict_type.value, "severity": c.severity.value,
"description": c.description, "confidence": c.confidence,
"verified": c.verified} for c in conflicts],
}
jobs[job_id]["status"] = "completed"
... # 記錄 end_time、duration_ms
except Exception as e:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = str(e)
注意這裡是 def 而不是 async def。detect_conflicts() 是同步呼叫,使用 Ollama 時一次要十幾秒:
async def,它會在 event loop 上直接執行,這十幾秒內整個伺服器無法處理其他請求,連 /health 都卡住def,FastAPI 的 BackgroundTasks 會把它丟到 threadpool 執行,event loop 保持暢通實測:在 SRS_USE_OLLAMA=1 下提交任務後立刻呼叫 /api/v1/health,0.01 秒就回應,任務稍後正常完成。
@app.get("/api/v1/conflicts/{job_id}", tags=["衝突檢測"])
async def get_results(job_id: str):
if job_id not in jobs:
raise HTTPException(status_code=404, detail=f"任務 {job_id} 未找到")
job = jobs[job_id]
return {"job_id": job_id, "status": job["status"], "results": job.get("results"),
"error": job.get("error"),
"timing": {"start_time": job["start_time"], "end_time": job.get("end_time"),
"duration_ms": job.get("duration_ms")}}
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
return JSONResponse(status_code=exc.status_code, content={
"error": "HTTPException", "code": exc.status_code,
"detail": exc.detail, # FastAPI 標準欄位,前端讀取 data.detail
"message": exc.detail, # 保留舊欄位以相容
"timestamp": datetime.now().isoformat(),
})
自訂錯誤處理器時很容易漏掉 detail。FastAPI 預設的錯誤格式是 {"detail": ...},前端(frontend/App.jsx)也是讀 data.detail;如果只回傳 message,前端永遠只能顯示「檢測失敗」這種籠統訊息。
/api/v1/health、/api/v1/info、/api/v1/metrics、/api/v1/conflicts 都是直接回傳字典的簡單端點,完整代碼見 src/api_main.py。
tests/test_day13_api.py 使用 FastAPI 的 TestClient,不需要真的啟動伺服器。TestClient 會在回應前把 BackgroundTasks 執行完,所以提交後可以馬上查到 completed:
cd srs-review-agent
python3 -m pytest tests/test_day13_api.py -v
tests/test_day13_api.py::test_root PASSED [ 10%]
tests/test_day13_api.py::test_health_check PASSED [ 20%]
tests/test_day13_api.py::test_info PASSED [ 30%]
tests/test_day13_api.py::test_detect_conflicts PASSED [ 40%]
tests/test_day13_api.py::test_get_results PASSED [ 50%]
tests/test_day13_api.py::test_list_jobs PASSED [ 60%]
tests/test_day13_api.py::test_metrics PASSED [ 70%]
tests/test_day13_api.py::test_invalid_request PASSED [ 80%]
tests/test_day13_api.py::test_not_found PASSED [ 90%]
tests/test_day13_api.py::test_api_documentation PASSED [100%]
============================== 10 passed in 0.20s ==============================
其中 test_get_results 會斷言任務狀態為 completed、conflicts_found == 2;test_invalid_request 斷言回應包含 detail。
# 預設模式(規則 + 模擬驗證)
uvicorn src.api_main:app --reload --port 8000
# 或使用 Mistral 7B(需先啟動 Ollama)
SRS_USE_OLLAMA=1 uvicorn src.api_main:app --port 8000
開啟 http://localhost:8000/docs 可以在瀏覽器直接試打每個端點。
1. 健康檢查
curl http://localhost:8000/api/v1/health
{"status": "healthy", "version": "1.0.0", "timestamp": "2026-09-25T19:45:11.822709",
"components": {"detector": "ready", "llm": "mock", "cache": "ready", "jobs": 0}}
2. 提交任務
curl -X POST http://localhost:8000/api/v1/conflicts/detect \
-H "Content-Type: application/json" \
-d '{"constraints": [
{"id": "REQ-1", "text": "系統支持多用戶並行存取"},
{"id": "REQ-2", "text": "系統採用單用戶模式"},
{"id": "REQ-3", "text": "所有數據必須加密存儲"},
{"id": "REQ-4", "text": "使用明文存儲以提高性能"}]}'
{"job_id": "job_20260925_194511_6da524", "status": "queued", "message": "檢測任務已提交 (舊版本)"}
3. 查詢結果
curl http://localhost:8000/api/v1/conflicts/job_20260925_194511_6da524
{
"job_id": "job_20260925_194511_6da524",
"status": "completed",
"results": {
"conflicts_found": 2,
"conflicts": [
{"req_id_1": "REQ-1", "req_id_2": "REQ-2", "type": "邏輯矛盾", "severity": "高",
"description": "檢測到 多用戶 vs 單用戶", "confidence": 0.95, "verified": false},
{"req_id_1": "REQ-3", "req_id_2": "REQ-4", "type": "安全性衝突", "severity": "高",
"description": "檢測到 加密 vs 明文", "confidence": 0.95, "verified": false}
]
},
"error": null,
"timing": {"start_time": "2026-09-25T19:45:11.824175",
"end_time": "2026-09-25T19:45:11.832748", "duration_ms": 0}
}
預設模式下 verified 為 false:兩個候選的置信度都是 0.95,selective 策略直接放行,沒有經過驗證。
4. 錯誤情況
curl -X POST http://localhost:8000/api/v1/conflicts/detect \
-H "Content-Type: application/json" \
-d '{"constraints": [{"id": "REQ-1", "text": "x"}, {"foo": 1}]}'
{"error": "HTTPException", "code": 400,
"detail": "第 2 個約束格式錯誤,需為字串或含 id、text 的物件",
"message": "第 2 個約束格式錯誤,需為字串或含 id、text 的物件",
"timestamp": "2026-09-25T19:45:12.336295"}
在 SRS_USE_OLLAMA=1 下提交一份 5 條需求的電商 SRS,連續送兩次相同的內容:
| 耗時 | LLM 呼叫 | 結果 | |
|---|---|---|---|
| 第 1 次 | 10.9 秒 | 2 次(批量驗證 1 + 補充 1) | 2 個衝突,verified: true |
| 第 2 次 | < 0.1 秒 | 0 次(緩存) | 與第 1 次相同 |
規則層的誤報「支持明文顯示訂單細節 vs 不需要加密用戶的個人信息」被批量驗證擋下。但補充層這次回報了「所有支付數據必須加密傳輸 vs 支持本地離線購物車」,這一對其實很難說是衝突,同時也漏掉了「實時同步到伺服器 vs 本地離線購物車」。這又回到 Day 12 的結論:LLM 結果需要人工複核,API 回傳的 verified 只代表「經過 LLM 驗證」,不代表「一定正確」。
jobs 就清空了,也無法跑多個 worker 共用任務。Day 15 會改存到資料庫。AdvancedCache 沒有加鎖。目前的單人使用情境問題不大,多人同時使用時應該加上 threading.Lock。git add src/api_main.py tests/test_day13_api.py
git commit -m "Day 13: FastAPI 衝突檢測 API(任務模式、輸入正規化、Ollama 開關)"
API 已經能用 curl 呼叫了,但一般使用者不會打 curl。明天 Day 14 會用 React 建立前端介面:輸入需求、提交檢測,並以表格查看任務與衝突結果。